Using CW-Analyzer for CPA Attack

This tutorial will take you through a complete attack on a software AES implementation. The specific implementation being attacked is a well-known AES implementation written in C, which is likely to be similar to other implementations used by proprietary systems.

Capturing Power Traces

Setup

We'll use some helper scripts to make setup and programming easier. If you're using an XMEGA or STM (CWLITEARM) target, binaries with the correct should be setup for you:

In [1]:
%run "Helper_Scripts/CWLite_Connect.ipynb"
In [2]:
%run "Helper_Scripts/Setup_Target_Generic.ipynb"
In [3]:
# uncomment based on your target
#%run "Helper_Scripts/Program_XMEGA.ipynb"
%run "Helper_Scripts/Program_STM.ipynb"
#%run "Helper_Scripts/No_Programmer.ipynb"
fw_path = "../../hardware/victims/firmware/simpleserial-aes/simpleserial-aes-cwlitearm.hex"
In [4]:
# program the target
program_target(scope, fw_path)
Detected known STMF32: STM32F302xB(C)/303xB(C)
Extended erase (0x44), this can take ten seconds or more
Attempting to programming 5879 bytes at 0x8000000
STM32F Programming flash...
STM32F Reading flash...
Verified flash OK, 5879 bytes

In addition, before we capture our traces, we'll need to create a ChipWhipserer project, since that's what Analyzer expects for an input:

In [5]:
project = cw.createProject("projects/Tutorial_B5.cwp", overwrite = True)

And we can get the class used to hold our traces by:

In [6]:
tc = project.getTraceFormat()

Capturing Traces

Below you can see the capture loop. The main body of the loop loads some new plaintext, arms the scope, sends the key and plaintext, then finally records and our new trace into our trace class.

In [7]:
#Capture Traces
from tqdm import tqdm
from chipwhisperer.capture.acq_patterns.basic import AcqKeyTextPattern_Basic
import numpy as np
import time

ktp = AcqKeyTextPattern_Basic(target=target)

N = 50  # Number of traces
target.init()
for i in tqdm(range(N), desc='Capturing traces'):
    # run aux stuff that should come before trace here

    key, text = ktp.newPair()  # manual creation of a key, text pair can be substituted here

    #target.reinit()

    target.setModeEncrypt()  # only does something for targets that support it
    target.loadEncryptionKey(key)
    target.loadInput(text)

    # run aux stuff that should run before the scope arms here

    scope.arm()

    # run aux stuff that should run after the scope arms here

    target.go()
    timeout = 50
    # wait for target to finish
    while target.isDone() is False and timeout:
        timeout -= 1
        time.sleep(0.01)

    ret = scope.capture()
    if ret:
        print('Timeout happened during acquisition')

    # run aux stuff that should happen after trace here
    _ = target.readOutput()  # clears the response from the serial port
    #traces.append(scope.getLastTrace())
    tc.addTrace(scope.getLastTrace(), text, "", key)
Capturing traces: 100%|██████████| 50/50 [00:09<00:00,  2.58it/s]

Now that we have our traces, we need to tell the project that the traces are loaded and add them to the project's trace manager.

In [8]:
tc._isloaded = True
project.traceManager().appendSegment(tc)

If you'd like, you can also save the project for later analysis (this closes the project, so if you run this block you'll need to reopen it in the next section):

In [9]:
from datetime import datetime
import copy

starttime = datetime.now()
prefix = starttime.strftime('%Y.%m.%d-%H.%M.%S') + "_"
tc.config.setConfigFilename(project.datadirectory + "traces/config_" + prefix + ".cfg")
tc.config.setAttr("prefix", prefix)
tc.config.setAttr("date", starttime.strftime('%Y-%m-%d %H:%M:%S'))
tc.closeAll()
project.save()

We're now done with the ChipWhisperer hardware, so we should disconnect from the scope and target:

In [10]:
# cleanup the connection to the target and scope
scope.dis()
target.dis()

Analysis

If you saved in the last part (or if you're continuing from where you left off), you'll need to reload the project:

In [11]:
import chipwhisperer as cw
project = cw.openProject("projects/Tutorial_B5.cwp")

Now that we have our traces, we can begin our attack! We'll start off by importing everything we need for the attack:

In [12]:
from chipwhisperer.analyzer.attacks.cpa import CPA
from chipwhisperer.analyzer.attacks.cpa_algorithms.progressive import CPAProgressive
from chipwhisperer.analyzer.attacks.models.AES128_8bit import AES128_8bit, SBox_output
from chipwhisperer.analyzer.preprocessing.add_noise_random import AddNoiseRandom

Next, we'll add our traces to a preprocessing module. We can feed project.traceManager() right into attack.setTraceSource(), but we could also add pre-processing inbetween (more about this later).

In [13]:
ppmod = AddNoiseRandom(project.traceManager())
ppmod.noise = 0.05
ppmod.enabled = False

And then we can setup our attack:

In [14]:
attack = CPA()
N = 50 #number of traces

leak_model = AES128_8bit(SBox_output)
attack.setAnalysisAlgorithm(CPAProgressive, leak_model)
attack.setTraceSource(ppmod)
attack.setTraceStart(0)
attack.setTracesPerAttack(N)
attack.setIterations(1)
attack.setReportingInterval(10)
attack.setTargetSubkeys([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15])
attack.setPointRange((0, -1))

And then actually run it:

In [15]:
attack_results = attack.processTracesNoGUI()

Once you see the above block complete, all the heavylifting is done! All that's left is to actually look at the data. Everything important is contained in the attack_results class that attack.processTracesNoGUI() returned.

We can find the max correlation for every one of the subkey by calling stats.findMaximums(), which returns a list of the subkeys, the point location of the max if calculated, and the correlation (which is a value between 0 and 1 that effectively tells us how well our guess fit the data).

Note the "point location of the max" is normally not calculated/tracked, and thus returns as a 0. Using the pandas library lets us print them nicely in a DataFrame. We have to transpose the frame to get our expected orientation:

In [16]:
import pandas as pd
stat_data = attack_results.findMaximums()
df = pd.DataFrame(stat_data).transpose()
print(df.head())
                             0                             1   \
0   [43, 0, 0.8948065690775246]  [126, 0, 0.8788471204188032]   
1  [154, 0, 0.6325118308789207]  [109, 0, 0.6449339560599383]   
2   [47, 0, 0.6022779660293133]  [237, 0, 0.6334841102203558]   
3  [246, 0, 0.6011994423594295]   [71, 0, 0.6173671065033942]   
4    [1, 0, 0.5852243565632621]  [154, 0, 0.6163472855586777]   

                             2                             3   \
0   [21, 0, 0.7797129766872424]    [22, 0, 0.906307562058493]   
1  [158, 0, 0.6801226112852271]  [210, 0, 0.6274572851898669]   
2   [125, 0, 0.644381057394079]  [129, 0, 0.6192020969072846]   
3  [184, 0, 0.6443152847677445]   [72, 0, 0.6178970702794885]   
4   [43, 0, 0.6222796843142168]   [93, 0, 0.6100784234840924]   

                             4                             5   \
0   [40, 0, 0.8983140608862451]  [174, 0, 0.9124421387326676]   
1   [178, 0, 0.624182692993621]    [6, 0, 0.6487412483085564]   
2   [200, 0, 0.619076671610155]  [117, 0, 0.6150052067920885]   
3   [25, 0, 0.6113851288249083]  [225, 0, 0.6139152900206214]   
4  [137, 0, 0.5996301713532626]   [71, 0, 0.6104789554278665]   

                             6                             7   \
0   [210, 0, 0.871476149313003]  [166, 0, 0.9049388059212977]   
1  [143, 0, 0.6365393101295379]  [204, 0, 0.6333511497899338]   
2   [89, 0, 0.6238824627341766]    [4, 0, 0.6139611212916778]   
3  [172, 0, 0.6183885154269986]  [206, 0, 0.6105190100769297]   
4  [241, 0, 0.6001242611441544]  [232, 0, 0.6053911274305913]   

                             8                             9   \
0  [171, 0, 0.9028045996002559]  [247, 0, 0.8618923232471549]   
1   [74, 0, 0.6601052917365203]  [208, 0, 0.6373186022452323]   
2  [199, 0, 0.6462045653387558]  [251, 0, 0.6153125531594686]   
3    [8, 0, 0.6207375717954899]   [19, 0, 0.6148805193759627]   
4  [201, 0, 0.6039789569611632]   [26, 0, 0.6053670958892682]   

                             10                            11  \
0   [21, 0, 0.9077438282819129]  [136, 0, 0.9166051828958239]   
1  [243, 0, 0.6287222797502712]  [248, 0, 0.6391363176699238]   
2  [207, 0, 0.6094020124135265]  [114, 0, 0.6086596056461537]   
3    [3, 0, 0.6027290457003367]   [116, 0, 0.602523138677484]   
4  [102, 0, 0.6025413846065617]  [189, 0, 0.5928377899062879]   

                             12                            13  \
0     [9, 0, 0.871330751944956]  [207, 0, 0.9152507970423274]   
1    [56, 0, 0.647524031534384]  [147, 0, 0.6764906380294111]   
2  [156, 0, 0.6321244640434249]  [184, 0, 0.6409537659540865]   
3  [180, 0, 0.6306400488596662]  [110, 0, 0.6395546787140917]   
4   [244, 0, 0.617466503193539]   [143, 0, 0.611183552342473]   

                             14                            15  
0    [79, 0, 0.913815436792074]   [60, 0, 0.8557049869939374]  
1   [41, 0, 0.6420319544432218]  [109, 0, 0.6505998451664586]  
2  [145, 0, 0.6340312570887068]   [64, 0, 0.6245400601730265]  
3  [202, 0, 0.6043757511909821]  [150, 0, 0.6059955880759745]  
4   [147, 0, 0.591026896356635]   [31, 0, 0.5910751941233922]  

Even better, we can use the .style method to customize this further. This also lets us chain formatting functions. For example, we can remove the extra 0 and clean up the data. Since we know the correct key, we can even do things like printing the key in a different colour!

You can do lots of formatting thanks to the pandas library! Check out https://pandas.pydata.org/pandas-docs/stable/style.html for more details.

In [17]:
key = project.traceManager().getKnownKey(0)
def format_stat(stat):
    return str("{:02X}<br>{:.3f}".format(stat[0], stat[2]))

def color_corr_key(row):
    global key
    ret = [""] * 16
    for i,bnum in enumerate(row):
        if bnum[0] == key[i]:
            ret[i] = "color: red"
        else:
            ret[i] = ""
    return ret

df.head().style.format(format_stat).apply(color_corr_key, axis=1)
Out[17]:
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
0 2B
0.895
7E
0.879
15
0.780
16
0.906
28
0.898
AE
0.912
D2
0.871
A6
0.905
AB
0.903
F7
0.862
15
0.908
88
0.917
09
0.871
CF
0.915
4F
0.914
3C
0.856
1 9A
0.633
6D
0.645
9E
0.680
D2
0.627
B2
0.624
06
0.649
8F
0.637
CC
0.633
4A
0.660
D0
0.637
F3
0.629
F8
0.639
38
0.648
93
0.676
29
0.642
6D
0.651
2 2F
0.602
ED
0.633
7D
0.644
81
0.619
C8
0.619
75
0.615
59
0.624
04
0.614
C7
0.646
FB
0.615
CF
0.609
72
0.609
9C
0.632
B8
0.641
91
0.634
40
0.625
3 F6
0.601
47
0.617
B8
0.644
48
0.618
19
0.611
E1
0.614
AC
0.618
CE
0.611
08
0.621
13
0.615
03
0.603
74
0.603
B4
0.631
6E
0.640
CA
0.604
96
0.606
4 01
0.585
9A
0.616
2B
0.622
5D
0.610
89
0.600
47
0.610
F1
0.600
E8
0.605
C9
0.604
1A
0.605
66
0.603
BD
0.593
F4
0.617
8F
0.611
93
0.591
1F
0.591

You should see red numbers printed at the top of a table. Congratulations, you've now completed a successful CPA attack against AES!

Next, we'll look at how we can use some of Analyzer's other features to improve the attack process, as well as better interpret the data we have.

Reporting Intervals

When we ran attack.processTracesNoGUI(), we processed all of the traces before getting any information back. While this works okay for shorter attacks like this, for longer ones it can helpful to get feedback during the attack. This can be done by creating a callback function and passing it to attack.processTracesNoGUI(). This function is called each time we pass our attack.setReportingInterval() (in this case, every 10 traces) and has access to everything a normal python function does.

Let's use this to update our table every 10 traces. Most of this is just putting our existing code into the callback function. We also need use the clear_output function to clear the table, as well as display() to actually get it to show up:

In [18]:
from IPython.display import clear_output
import numpy as np
        
def stats_callback():
    attack_results = attack.getStatistics()
    attack_results.setKnownkey(key)
    stat_data = attack_results.findMaximums()
    df = pd.DataFrame(stat_data).transpose()
    clear_output(wait=True)
    display(df.head().style.format(format_stat).apply(color_corr_key,axis=1))
    
attack.setReportingInterval(10)
attack_results = attack.processTracesNoGUI(stats_callback)
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
0 2B
0.895
7E
0.879
15
0.780
16
0.906
28
0.898
AE
0.912
D2
0.871
A6
0.905
AB
0.903
F7
0.862
15
0.908
88
0.917
09
0.871
CF
0.915
4F
0.914
3C
0.856
1 9A
0.633
6D
0.645
9E
0.680
D2
0.627
B2
0.624
06
0.649
8F
0.637
CC
0.633
4A
0.660
D0
0.637
F3
0.629
F8
0.639
38
0.648
93
0.676
29
0.642
6D
0.651
2 2F
0.602
ED
0.633
7D
0.644
81
0.619
C8
0.619
75
0.615
59
0.624
04
0.614
C7
0.646
FB
0.615
CF
0.609
72
0.609
9C
0.632
B8
0.641
91
0.634
40
0.625
3 F6
0.601
47
0.617
B8
0.644
48
0.618
19
0.611
E1
0.614
AC
0.618
CE
0.611
08
0.621
13
0.615
03
0.603
74
0.603
B4
0.631
6E
0.640
CA
0.604
96
0.606
4 01
0.585
9A
0.616
2B
0.622
5D
0.610
89
0.600
47
0.610
F1
0.600
E8
0.605
C9
0.604
1A
0.605
66
0.603
BD
0.593
F4
0.617
8F
0.611
93
0.591
1F
0.591

A default jupyter callback is also available:

In [19]:
import chipwhisperer as cw
cb = cw.getJupyterCallback(attack)
attack_results = attack.processTracesNoGUI(cb)
Finished traces 40 to 50
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
PGE= 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
0 2B
0.895
7E
0.879
15
0.780
16
0.906
28
0.898
AE
0.912
D2
0.871
A6
0.905
AB
0.903
F7
0.862
15
0.908
88
0.917
09
0.871
CF
0.915
4F
0.914
3C
0.856
1 9A
0.633
6D
0.645
9E
0.680
D2
0.627
B2
0.624
06
0.649
8F
0.637
CC
0.633
4A
0.660
D0
0.637
F3
0.629
F8
0.639
38
0.648
93
0.676
29
0.642
6D
0.651
2 2F
0.602
ED
0.633
7D
0.644
81
0.619
C8
0.619
75
0.615
59
0.624
04
0.614
C7
0.646
FB
0.615
CF
0.609
72
0.609
9C
0.632
B8
0.641
91
0.634
40
0.625
3 F6
0.601
47
0.617
B8
0.644
48
0.618
19
0.611
E1
0.614
AC
0.618
CE
0.611
08
0.621
13
0.615
03
0.603
74
0.603
B4
0.631
6E
0.640
CA
0.604
96
0.606
4 01
0.585
9A
0.616
2B
0.622
5D
0.610
89
0.600
47
0.610
F1
0.600
E8
0.605
C9
0.604
1A
0.605
66
0.603
BD
0.593
F4
0.617
8F
0.611
93
0.591
1F
0.591

Here we used a reporting interval of 10 traces. Depending on the attack and what you want to learn from it, you may want to use higher or lower values: in general reporting less often is faster, but more frequent reporting can allow you to end a long attack early. More frequent reporting also increases the resolution of some plot data (which we will look at next).

Plot Data

Analyzer also includes a module to create plots to help you interpret the data. These act on one subkey at a time and return some data that we can plot using bokeh (or your graphing module of choice). Let's start by grabbing the class that does all the calculations:

In [20]:
plot_data = cw.analyzerPlots(attack_results)

Output Vs. Time

We'll start by looking at the Output Vs. Time module, which will allow us to plot correlation of our guesses in time. This is useful for finding exactly where the operations we're attacking are. Like in previous tutorials, we'll use bokeh to plot the data we get back.

The method we're interested in is getPlotData(bnum), which returns in a list: [xrange, correct_key, incorrect_key_data, incorrect_key_data] for the position bnum passed to it. The method returns two sets of incorrect key data because one is for the key guesses below the correct one, and the other is for guesses above the correct one.

We'll start by just looking at the 0th subkey. Once we get this data back we'll plot the correct key in red, and the rest in green.

In [21]:
from bokeh.plotting import figure, show
from bokeh.io import output_notebook

ret = plot_data.outputVsTime(0)

output_notebook()
p = figure()
p.line(ret[0], ret[2], line_color='green')
p.line(ret[0], ret[3], line_color='green')

p.line(ret[0], ret[1], line_color='red')
show(p)
Loading BokehJS ...

You should see some distinctive red spikes in your plot. The largest of these is where the sbox lookup is actually happening (the smaller ones are typically other AES operations that move the sbox data around).

Let's repeat this for all the subkeys. This is quite a bit more data to plot, so give it a few seconds:

In [22]:
rets = []
for i in range(0, 16):
    rets.append(plot_data.outputVsTime(i))

p = figure()
for ret in rets:
    p.line(ret[0], ret[2], line_color='green')
    p.line(ret[0], ret[3], line_color='green')
    
for ret in rets:
    p.line(ret[0], ret[1], line_color='red')

show(p)

This information can be useful in many ways. For example, you can probably see the first 16 spikes that make up the sbox lookup are a small portion of the total trace length. If we ever needed to rerun the attack, we could capture a much smaller number of samples and speed up analysis significantly!

PGE vs. Traces

The next data we'll look at is a plot of partial guessing entropy (PGE) vs. the number of traces. As mentioned before, PGE is just how many spots away from the top the actual subkey is in our table of guesses. For example, if there are 7 subkey guesses that have a higher correlation than the actual subkey, the subkey has a PGE of 7.

This plot is useful for seeing how many traces were needed to actually break the AES implementation. Keep in mind, however, that the resolution of the plot is determined by the reporting interval (also note that attack_results.findMaximums() must be called in the callback function). In our case, we have a reporting interval of 10, so we'll have a resolution of 10 traces.

This module's getPlotData() is similar to the previous plot in that it takes bnum as an argument and returns a list of [xrange, PGE]. Plotting this data is quite a bit faster than the previous example, we'll just plot all 16 of the bnum now.

In [23]:
p = figure()

for bnum in range(16):
    ret = plot_data.pgeVsTrace(bnum)
    p.line(ret[0], ret[1], line_color='red')
show(p)

You should see a number of lines that start off with high values, then rapidly drop off. You may notice that we broke the AES implementation without needing to use all of our traces.

Even though we may have broken the AES implementation in fewer traces, we may not want to reduce how many traces we capture. Remember that, while we know the key here, for a real attack we won't and therefore must use the correlation to determine when we've broken a key. Our next plot will help us to determine how feesible capturing fewer traces is.

Correlation vs. Traces

The last plot we'll take a look at is correlation vs the number of traces. Like with PGE vs. Traces, this plot's resolution is determined by the reporting interval (10 in our case). One again, this is a plot with a lot of data, so we'll start of by just plotting one subkey:

This module's getPlotData() returns a list of [xrange, [data_for_kguess]], so we'll need to plot each guess for each subkey. Like before, we'll do the plot for the correct subkey in red and the rest in green.

In [24]:
ret = plot_data.corrVsTrace(0)
p = figure()
for i in range(255):
    if i == key[0]:
        p.line(ret[0], ret[1][i], line_color='red')
    else:
        p.line(ret[0], ret[1][i], line_color='green')
        
show(p)

As you can see, all the subkey guesses start of with large correlations, but all of them except for the correct guess quickly drop off. If you didn't know the key, at what point would you be sure that the guess with the highest correlation was actually the correct subkey?

Let's continue and plot all of the subkeys (give this one some time):

In [25]:
p = figure()
for bnum in range(16):
    ret = plot_data.corrVsTrace(bnum)
    for i in range(255):
        if i == key[bnum]:
            p.line(ret[0], ret[1][i], line_color='red')
        else:
            p.line(ret[0], ret[1][i], line_color='green')
            
show(p)

Like in the first plot, you should see the red lines remain high while the green ones drop off. At what point would you be sure that you've broken all the subkeys? Is it higher than when all of the PGE lines reached zero?

Conclusion

You should now have completed a successful CPA attack and learned about some on Analyzer's features for improving your attack!

You can move onto more advanced tutorials, especially showing you how the actual attack works when performed manually (Tutorial B6). This tutorial also utilized tiny-AES128-C for Arm targets, which uses the same operations as the XMEGA target. A later tutorial will preform this attack on a more typical 32 bit AES implementation.

Tests

In [26]:
key = project.traceManager().getKnownKey(0)
recv_key = [kguess[0][0] for kguess in attack_results.findMaximums()]
assert (key == recv_key).all(), "Failed to recover encryption key\nGot: {}\nExpected: {}".format(recv_key, key)
In [27]:
assert (attack_results.pge == [0]*16), "PGE for some bytes not zero: {}".format(attack_results.pge)
In [28]:
max_corrs = [kguess[0][2] for kguess in attack_results.findMaximums()]
assert (np.all([corr > 0.75 for corr in max_corrs])), "Low correlation in attack (corr <= 0.75): {}".format(max_corrs)
In [ ]:
 
In [ ]: